Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.26% covered (success)
99.26%
134 / 135
96.30% covered (success)
96.30%
26 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Connection
99.26% covered (success)
99.26%
134 / 135
96.30% covered (success)
96.30%
26 / 27
41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getPdo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDriverName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDialect
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resetState
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setStatementPool
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStatementPool
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setEventDispatcher
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEventDispatcher
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dispatch
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handlePdoException
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 prepare
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 buildStatementKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
3
 query
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
3
 quote
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 transaction
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 commit
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 rollBack
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 inTransaction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExecuteState
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastInsertId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastError
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastErrno
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAttribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAttribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace tommyknocker\pdodb\connection;
6
7use PDO;
8use PDOException;
9use PDOStatement;
10use Psr\EventDispatcher\EventDispatcherInterface;
11use Psr\Log\LoggerInterface;
12use tommyknocker\pdodb\dialects\DialectInterface;
13use tommyknocker\pdodb\events\QueryErrorEvent;
14use tommyknocker\pdodb\events\QueryExecutedEvent;
15use tommyknocker\pdodb\events\TransactionCommittedEvent;
16use tommyknocker\pdodb\events\TransactionRolledBackEvent;
17use tommyknocker\pdodb\events\TransactionStartedEvent;
18use tommyknocker\pdodb\exceptions\ExceptionFactory;
19
20/**
21 * Connection.
22 *
23 * Thin wrapper around PDO. Responsible for lifecycle, tracing and error normalization.
24 */
25class Connection implements ConnectionInterface
26{
27    use ConnectionLogger;
28
29    /** @var PDO PDO instance */
30    protected PDO $pdo;
31
32    /** @var DialectInterface Dialect instance for database-specific SQL */
33    protected DialectInterface $dialect;
34
35    /** @var PDOStatement|null Last prepared statement */
36    protected ?PDOStatement $stmt = null;
37
38    /** @var ConnectionState Connection state manager */
39    protected ConnectionState $state;
40
41    /** @var PreparedStatementPool|null Prepared statement pool */
42    protected ?PreparedStatementPool $statementPool = null;
43
44    /** @var EventDispatcherInterface|null Event dispatcher */
45    protected ?EventDispatcherInterface $eventDispatcher = null;
46
47    /**
48     * Connection constructor.
49     *
50     * @param PDO $pdo The PDO instance to use.
51     * @param DialectInterface $dialect The dialect to use.
52     * @param LoggerInterface|null $logger The logger to use.
53     */
54    public function __construct(PDO $pdo, DialectInterface $dialect, ?LoggerInterface $logger = null)
55    {
56        $this->pdo = $pdo;
57        $this->dialect = $dialect;
58        $this->logger = $logger;
59        $this->state = new ConnectionState();
60    }
61
62    /**
63     * Returns the PDO instance.
64     *
65     * @return PDO The PDO instance.
66     */
67    public function getPdo(): PDO
68    {
69        return $this->pdo;
70    }
71
72    /**
73     * Returns the driver name.
74     *
75     * @return string The driver name.
76     */
77    public function getDriverName(): string
78    {
79        return (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
80    }
81
82    /**
83     * Returns the dialect.
84     *
85     * @return DialectInterface The dialect.
86     */
87    public function getDialect(): DialectInterface
88    {
89        return $this->dialect;
90    }
91
92    /**
93     * Resets the state.
94     */
95    public function resetState(): void
96    {
97        $this->state->reset();
98    }
99
100    /**
101     * Set the prepared statement pool.
102     *
103     * @param PreparedStatementPool|null $pool The pool instance or null to disable
104     */
105    public function setStatementPool(?PreparedStatementPool $pool): void
106    {
107        $this->statementPool = $pool;
108    }
109
110    /**
111     * Get the prepared statement pool.
112     *
113     * @return PreparedStatementPool|null The pool instance or null if not set
114     */
115    public function getStatementPool(): ?PreparedStatementPool
116    {
117        return $this->statementPool;
118    }
119
120    /**
121     * Set the event dispatcher.
122     *
123     * @param EventDispatcherInterface|null $dispatcher The dispatcher instance or null to disable
124     */
125    public function setEventDispatcher(?EventDispatcherInterface $dispatcher): void
126    {
127        $this->eventDispatcher = $dispatcher;
128    }
129
130    /**
131     * Get the event dispatcher.
132     *
133     * @return EventDispatcherInterface|null The dispatcher instance or null if not set
134     */
135    public function getEventDispatcher(): ?EventDispatcherInterface
136    {
137        return $this->eventDispatcher;
138    }
139
140    /**
141     * Dispatch an event if dispatcher is available.
142     *
143     * @param object $event The event to dispatch
144     */
145    protected function dispatch(object $event): void
146    {
147        $this->eventDispatcher?->dispatch($event);
148    }
149
150    /**
151     * Handle PDO exceptions with consistent error processing.
152     *
153     * @param PDOException $e The PDO exception
154     * @param string $operation The operation that failed
155     * @param string|null $sql The SQL query (if applicable)
156     * @param array<string, mixed> $context Additional context
157     *
158     * @throws \tommyknocker\pdodb\exceptions\DatabaseException Always throws a specialized exception
159     */
160    protected function handlePdoException(
161        PDOException $e,
162        string $operation,
163        ?string $sql = null,
164        array $context = []
165    ): never {
166        $this->state->setError($e->getMessage(), (int)$e->getCode());
167
168        $dbException = ExceptionFactory::createFromPdoException(
169            $e,
170            $this->getDriverName(),
171            $sql,
172            array_merge(['operation' => $operation], $context)
173        );
174
175        $this->logOperationError($operation, $e, $sql, [
176            'exception_type' => $dbException::class,
177            'category' => $dbException->getCategory(),
178            'retryable' => $dbException->isRetryable(),
179        ]);
180
181        // Dispatch error event
182        $this->dispatch(new QueryErrorEvent(
183            $sql ?? '',
184            $context['params'] ?? [],
185            $dbException,
186            $this->getDriverName()
187        ));
188
189        throw $dbException;
190    }
191
192    /**
193     * Prepare SQL query.
194     *
195     * @param string $sql
196     * @param array<int|string, string|int|float|bool|null> $params
197     *
198     * @return $this
199     */
200    public function prepare(string $sql, array $params = []): static
201    {
202        $this->logOperationStart('prepare', $sql);
203
204        try {
205            $pool = $this->statementPool;
206            if ($pool !== null && $pool->isEnabled()) {
207                $key = $this->buildStatementKey($sql);
208                $cachedStmt = $pool->get($key);
209                if ($cachedStmt !== null) {
210                    $this->stmt = $cachedStmt;
211                    $this->logOperationEnd('prepare', $this->stmt->queryString, ['cached' => true]);
212                    return $this;
213                }
214                // Prepare new statement and cache it
215                $this->stmt = $this->pdo->prepare($sql, $params);
216                $pool->put($key, $this->stmt);
217                $this->logOperationEnd('prepare', $this->stmt->queryString);
218                return $this;
219            }
220
221            $this->stmt = $this->pdo->prepare($sql, $params);
222            $this->logOperationEnd('prepare', $this->stmt->queryString);
223            return $this;
224        } catch (PDOException $e) {
225            $this->handlePdoException($e, 'prepare', $this->stmt?->queryString);
226        }
227    }
228
229    /**
230     * Build a stable cache key for prepared statements.
231     *
232     * @param string $sql The SQL query
233     *
234     * @return string The cache key
235     */
236    protected function buildStatementKey(string $sql): string
237    {
238        // Include driver name to avoid collisions between dialects
239        // Hash the SQL for stable, short keys
240        return sha1($this->getDriverName() . '|' . $sql);
241    }
242
243    /**
244     * Executes a SQL statement.
245     *
246     * @param array<int|string, string|int|float|bool|null> $params
247     *
248     * @return PDOStatement The PDOStatement instance.
249     */
250    public function execute(array $params = []): PDOStatement
251    {
252        $this->resetState();
253        $stmt = $this->stmt;
254
255        if ($stmt === null) {
256            throw new \RuntimeException('No statement prepared. Call prepare() first.');
257        }
258
259        $this->logOperationStart('execute', $stmt->queryString);
260        $startTime = microtime(true);
261
262        try {
263            $this->state->setExecuteState($stmt->execute($params));
264            $this->state->setLastQuery($stmt->queryString);
265            $executionTime = (microtime(true) - $startTime) * 1000; // milliseconds
266            $rowsAffected = $stmt->rowCount();
267
268            $this->logOperationEnd('execute', $stmt->queryString, [
269                'rows_affected' => $rowsAffected,
270                'success' => (bool)$this->state->getExecuteState(),
271                'execution_time_ms' => $executionTime,
272            ]);
273
274            // Dispatch query executed event
275            $this->dispatch(new QueryExecutedEvent(
276                $stmt->queryString,
277                $params,
278                $executionTime,
279                $rowsAffected,
280                $this->getDriverName(),
281                false
282            ));
283
284            // Clear statement reference to allow GC (statement is returned to caller)
285            // Note: We return the statement, caller should close cursor when done
286            // For pooled statements, closeCursor() can be called safely - it only closes
287            // the result set cursor, not the statement itself, so pooled statements can be reused
288            $this->stmt = null;
289            return $stmt;
290        } catch (PDOException $e) {
291            $this->handlePdoException($e, 'execute', $stmt->queryString, ['params' => $params]);
292        }
293    }
294
295    /**
296     * Executes a SQL query.
297     *
298     * @param string $sql The SQL query to execute.
299     *
300     * @return PDOStatement|false The PDOStatement instance or false on failure.
301     */
302    public function query(string $sql): PDOStatement|false
303    {
304        $this->resetState();
305        $this->logOperationStart('query', $sql);
306        $startTime = microtime(true);
307
308        try {
309            $stmt = $this->pdo->query($sql);
310            if ($stmt !== false) {
311                $executionTime = (microtime(true) - $startTime) * 1000; // milliseconds
312                $rowsAffected = $stmt->rowCount();
313
314                $this->state->setLastQuery($sql);
315                $this->logOperationEnd('query', $sql, [
316                    'rows_affected' => $rowsAffected,
317                    'execution_time_ms' => $executionTime,
318                ]);
319
320                // Dispatch query executed event
321                $this->dispatch(new QueryExecutedEvent(
322                    $sql,
323                    [],
324                    $executionTime,
325                    $rowsAffected,
326                    $this->getDriverName(),
327                    false
328                ));
329            }
330            return $stmt;
331        } catch (PDOException $e) {
332            $this->handlePdoException($e, 'query', $sql);
333        }
334    }
335
336    /**
337     * Quotes a string for use in a query.
338     *
339     * @param mixed $value The value to quote.
340     *
341     * @return string|false The quoted string or false on failure.
342     */
343    public function quote(mixed $value): string|false
344    {
345        return $this->pdo->quote((string)$value);
346    }
347
348    /**
349     * Begins a transaction.
350     *
351     * @return bool True on success, false on failure.
352     */
353    public function transaction(): bool
354    {
355        $this->logOperationStart('transaction.begin');
356
357        try {
358            $result = $this->pdo->beginTransaction();
359            if ($result) {
360                $this->dispatch(new TransactionStartedEvent($this->getDriverName()));
361            }
362            return $result;
363        } catch (PDOException $e) {
364            $this->handlePdoException($e, 'transaction.begin');
365        }
366    }
367
368    /**
369     * Commits a transaction.
370     *
371     * @return bool True on success, false on failure.
372     */
373    public function commit(): bool
374    {
375        try {
376            $startTime = microtime(true);
377            $res = $this->pdo->commit();
378            if ($res) {
379                $duration = (microtime(true) - $startTime) * 1000; // milliseconds
380                $this->dispatch(new TransactionCommittedEvent($this->getDriverName(), $duration));
381            }
382            $this->logOperationEnd('transaction.commit');
383            return $res;
384        } catch (PDOException $e) {
385            $this->handlePdoException($e, 'transaction.commit');
386        }
387    }
388
389    /**
390     * Rolls back a transaction.
391     *
392     * @return bool True on success, false on failure.
393     */
394    public function rollBack(): bool
395    {
396        try {
397            $startTime = microtime(true);
398            $res = $this->pdo->rollBack();
399            if ($res) {
400                $duration = (microtime(true) - $startTime) * 1000; // milliseconds
401                $this->dispatch(new TransactionRolledBackEvent($this->getDriverName(), $duration));
402            }
403            $this->logOperationEnd('transaction.rollback');
404            return $res;
405        } catch (PDOException $e) {
406            $this->handlePdoException($e, 'transaction.rollback');
407        }
408    }
409
410    /**
411     * Checks if a transaction is active.
412     *
413     * @return bool True if a transaction is active, false otherwise.
414     */
415    public function inTransaction(): bool
416    {
417        return $this->pdo->inTransaction();
418    }
419
420    /**
421     * Returns the last execute state.
422     *
423     * @return bool|null The last execute state.
424     */
425    public function getExecuteState(): ?bool
426    {
427        return $this->state->getExecuteState();
428    }
429
430    /**
431     * Returns the last insert ID.
432     *
433     * @param string|null $name The name of the sequence to use.
434     *
435     * @return false|string The last insert ID or false on failure.
436     */
437    public function getLastInsertId(?string $name = null): false|string
438    {
439        return $this->pdo->lastInsertId($name);
440    }
441
442    /**
443     * Returns the last query.
444     *
445     * @return string|null The last query or null if no query has been executed.
446     */
447    public function getLastQuery(): ?string
448    {
449        return $this->state->getLastQuery();
450    }
451
452    /**
453     * Returns the last error message.
454     *
455     * @return string|null The last error message or null if no error has occurred.
456     */
457    public function getLastError(): ?string
458    {
459        return $this->state->getLastError();
460    }
461
462    /**
463     * Returns the last error number.
464     *
465     * @return int The last error number.
466     */
467    public function getLastErrno(): int
468    {
469        return $this->state->getLastErrno();
470    }
471
472    /**
473     * Set a PDO attribute.
474     *
475     * @param int $attribute The attribute to set.
476     * @param mixed $value The value to set.
477     *
478     * @return bool True on success, false on failure.
479     */
480    public function setAttribute(int $attribute, mixed $value): bool
481    {
482        return $this->pdo->setAttribute($attribute, $value);
483    }
484
485    /**
486     * Get a PDO attribute.
487     *
488     * @param int $attribute The attribute to get.
489     *
490     * @return mixed The attribute value.
491     */
492    public function getAttribute(int $attribute): mixed
493    {
494        return $this->pdo->getAttribute($attribute);
495    }
496}